feat(runway): git-backed merger with REBASE - #460
Open
behinddwalls wants to merge 1 commit into
Open
Conversation
This was referenced Jul 29, 2026
behinddwalls
marked this pull request as ready for review
July 30, 2026 14:49
behinddwalls
commented
Jul 30, 2026
behinddwalls
commented
Jul 30, 2026
behinddwalls
force-pushed
the
preetam/runway-dlq-reconciler
branch
from
July 30, 2026 16:12
6c0f8d9 to
42a0cd1
Compare
behinddwalls
force-pushed
the
preetam/runway-git-merger-rebase
branch
from
July 30, 2026 16:12
ffa903d to
bab2ff3
Compare
behinddwalls
marked this pull request as draft
July 30, 2026 16:14
behinddwalls
force-pushed
the
preetam/runway-dlq-reconciler
branch
from
July 30, 2026 16:16
42a0cd1 to
d8ee8ae
Compare
behinddwalls
force-pushed
the
preetam/runway-git-merger-rebase
branch
2 times, most recently
from
July 30, 2026 16:51
b56440e to
4820747
Compare
behinddwalls
marked this pull request as ready for review
July 30, 2026 17:06
behinddwalls
force-pushed
the
preetam/runway-dlq-reconciler
branch
from
July 30, 2026 17:13
d8ee8ae to
b358333
Compare
behinddwalls
force-pushed
the
preetam/runway-git-merger-rebase
branch
from
July 30, 2026 17:13
4820747 to
8130e8f
Compare
behinddwalls
force-pushed
the
preetam/runway-dlq-reconciler
branch
from
July 30, 2026 17:55
b358333 to
d976bb9
Compare
behinddwalls
force-pushed
the
preetam/runway-git-merger-rebase
branch
from
July 30, 2026 17:55
8130e8f to
d2b5239
Compare
## Summary ### Why? Runway's `merger` extension has had exactly one implementation — `noop`, which always succeeds. Nothing actually merges anything. This lands the first real backend: a `Merger` driven by the `git` CLI against a local checkout. It ships REBASE only. The strategy-specific apply paths are small and independent, but the machinery underneath them — the pinned git runtime, object resolution, the reset/apply/push cycle, contention retry, dry-run discard, conflict classification — is shared and is the bulk of what needs review. Landing it with one strategy keeps that review separable from the per-strategy mechanics that follow in this stack. ### What? Adds `runway/extension/merger/git`. **A change is a range of commits, not a commit.** A change URI pins a pull request to a single head SHA, but a pull request is routinely several commits. Applying the head alone applies only that commit's diff against its own parent: it conflicts against context its predecessors would have established, or — when the commits touch different files — succeeds while silently dropping everything before the head. So the unit replayed is the range from the change's merge base with the target up to its head. A change already contained in the target has an empty range and is a no-op, which is what keeps redelivery idempotent. A range runs through git's sequencer, which changes the control flow versus a single pick: the operation stops on a commit it declines and `--skip` advances to the next rather than ending. Two kinds of commit stop it harmlessly — one already present on the target, and one that was empty to begin with — and both are skipped. Anything else is a real conflict, and the in-progress pick is aborted so the checkout stays usable. The commits an apply produced are read back off the checkout rather than tracked per-invocation, which stays correct when the sequencer drops some. **Referenced commits are guaranteed present before anything is applied.** The default fetch refspec is `+refs/heads/*`, which does not cover a provider's change refs: a pull request head never also pushed as a branch — the normal case for a fork — is simply absent, and the apply then fails with git's "bad object", indistinguishable from a conflict. Every commit the request names is now fetched and verified up front, for all steps, so an unusable request fails without having mutated the checkout. Commits are requested by SHA (relying on the server serving a want for a reachable-but-unadvertised object, which github.com allows), falling back to the provider's canonical ref, with deployment-supplied refspecs as a last resort. Neither fetch is shallow — the range needs ancestry. A commit a *reachable* remote cannot supply is terminal; a remote that will not answer stays retryable. The first is a property of the request, the second a property of the moment. **One seam for change providers.** Every URI is reduced to the only three things the merger needs — the commit to apply, the ref the provider publishes it under, and a label for synthesized messages. `github://` and `git://` are supported; an unrecognized scheme is terminal. Adding a provider is one case in that mapping rather than a change to any apply path. **Optional staleness check.** Fetching by SHA guarantees the merger applies exactly the commit the URI names, not that it is still the change's head — a force-push leaves the superseded commit fetchable on most hosts. When enabled, each change's canonical ref is read (one ref advertisement, no object transfer) and a mismatch is terminal. **Atomicity, contention, dry run.** Nothing reaches the remote until the final push. If the push is rejected because the remote tip moved, the whole reset/apply/push cycle retries up to a bounded number of attempts. `CheckMergeability` runs the identical apply path but never pushes, then resets and reports empty outputs, committing intermediate steps locally so a multi-step check sees the same conflict surface a real merge would. **Runtime hygiene.** Every invocation uses an explicitly pinned git runtime and a scrubbed environment — no system or global config, no interactive prompts. That leaves no ambient identity, so the committer is injected per-invocation. `isConcreteStrategy` currently admits only REBASE; SQUASH_REBASE, MERGE and PROMOTE are rejected as invalid requests until their apply paths land later in this stack. The merger is not wired into the server yet, so this adds no production behavior. ## Test Plan ✅ `bazel test //runway/...` — 6/6 targets pass, including the new `//runway/extension/merger/git` suite (40s) The suite drives a real git binary against throwaway repositories. Beyond the single-commit cases (stacked URIs, already-landed changes, multi-step requests, conflicts, checkout recovery, contention retry, give-up after max attempts, DEFAULT resolution, dry runs), it covers what this change is actually about: a multi-commit change expressed as **one** URI for both disjoint and overlapping edits, a partially-landed change, an empty commit inside a range, a conflict inside a range leaving no sequencer state behind, stacked multi-commit changes not duplicating each other's commits, a head reachable only via its pull-request ref, an unavailable commit classified as invalid rather than conflicting, the staleness check on and off, and provider resolution across schemes. The regression tests were verified to fail against head-only picking: reverting just that line fails 5 of them, and they pass with the range applied.
behinddwalls
force-pushed
the
preetam/runway-dlq-reconciler
branch
from
July 30, 2026 18:05
d976bb9 to
c766cf8
Compare
behinddwalls
force-pushed
the
preetam/runway-git-merger-rebase
branch
from
July 30, 2026 18:05
d2b5239 to
e96766d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Why?
Runway's
mergerextension has had exactly one implementation —noop, which always succeeds. Nothing actually merges anything. This lands the first real backend: aMergerdriven by thegitCLI against a local checkout.It ships REBASE only. The strategy-specific apply paths are small and independent, but the machinery underneath them — the pinned git runtime, object resolution, the reset/apply/push cycle, contention retry, dry-run discard, conflict classification — is shared and is the bulk of what needs review. Landing it with one strategy keeps that review separable from the per-strategy mechanics that follow in this stack.
What?
Adds
runway/extension/merger/git.A change is a range of commits, not a commit. A change URI pins a pull request to a single head SHA, but a pull request is routinely several commits. Applying the head alone applies only that commit's diff against its own parent: it conflicts against context its predecessors would have established, or — when the commits touch different files — succeeds while silently dropping everything before the head. So the unit replayed is the range from the change's merge base with the target up to its head. A change already contained in the target has an empty range and is a no-op, which is what keeps redelivery idempotent.
A range runs through git's sequencer, which changes the control flow versus a single pick: the operation stops on a commit it declines and
--skipadvances to the next rather than ending. Two kinds of commit stop it harmlessly — one already present on the target, and one that was empty to begin with — and both are skipped. Anything else is a real conflict, and the in-progress pick is aborted so the checkout stays usable. The commits an apply produced are read back off the checkout rather than tracked per-invocation, which stays correct when the sequencer drops some.Referenced commits are guaranteed present before anything is applied. The default fetch refspec is
+refs/heads/*, which does not cover a provider's change refs: a pull request head never also pushed as a branch — the normal case for a fork — is simply absent, and the apply then fails with git's "bad object", indistinguishable from a conflict. Every commit the request names is now fetched and verified up front, for all steps, so an unusable request fails without having mutated the checkout. Commits are requested by SHA (relying on the server serving a want for a reachable-but-unadvertised object, which github.com allows), falling back to the provider's canonical ref, with deployment-supplied refspecs as a last resort. Neither fetch is shallow — the range needs ancestry.A commit a reachable remote cannot supply is terminal; a remote that will not answer stays retryable. The first is a property of the request, the second a property of the moment.
One seam for change providers. Every URI is reduced to the only three things the merger needs — the commit to apply, the ref the provider publishes it under, and a label for synthesized messages.
github://andgit://are supported; an unrecognized scheme is terminal. Adding a provider is one case in that mapping rather than a change to any apply path.Optional staleness check. Fetching by SHA guarantees the merger applies exactly the commit the URI names, not that it is still the change's head — a force-push leaves the superseded commit fetchable on most hosts. When enabled, each change's canonical ref is read (one ref advertisement, no object transfer) and a mismatch is terminal.
Atomicity, contention, dry run. Nothing reaches the remote until the final push. If the push is rejected because the remote tip moved, the whole reset/apply/push cycle retries up to a bounded number of attempts.
CheckMergeabilityruns the identical apply path but never pushes, then resets and reports empty outputs, committing intermediate steps locally so a multi-step check sees the same conflict surface a real merge would.Runtime hygiene. Every invocation uses an explicitly pinned git runtime and a scrubbed environment — no system or global config, no interactive prompts. That leaves no ambient identity, so the committer is injected per-invocation.
isConcreteStrategycurrently admits only REBASE; SQUASH_REBASE, MERGE and PROMOTE are rejected as invalid requests until their apply paths land later in this stack. The merger is not wired into the server yet, so this adds no production behavior.Test Plan
✅
bazel test //runway/...— 6/6 targets pass, including the new//runway/extension/merger/gitsuite (40s)The suite drives a real git binary against throwaway repositories. Beyond the single-commit cases (stacked URIs, already-landed changes, multi-step requests, conflicts, checkout recovery, contention retry, give-up after max attempts, DEFAULT resolution, dry runs), it covers what this change is actually about: a multi-commit change expressed as one URI for both disjoint and overlapping edits, a partially-landed change, an empty commit inside a range, a conflict inside a range leaving no sequencer state behind, stacked multi-commit changes not duplicating each other's commits, a head reachable only via its pull-request ref, an unavailable commit classified as invalid rather than conflicting, the staleness check on and off, and provider resolution across schemes.
The regression tests were verified to fail against head-only picking: reverting just that line fails 5 of them, and they pass with the range applied.
Stack